第十章 调试


错误类型

可以试着在纸上写下执行程序的代码,这个过程称为空运行( dry running )。针对非常重要的例程,先记下其输入,然后手工计算其输出。调试程序不一定非要用计算机。

常用调试技巧

程序调试可以分为如下阶段:

代码检查

有些工具可以帮你完成代码检查工作,编译器就是其中比较明显的一个。如果有语法错误,它会告诉你。

gcc -Wall -pedantic -ansi

-Wall 打开了所有的语法警告,-pedantic 检查语法是否符合 ANSI/ISO 标准,-ansi 表示使用的是C89 。

取样法

取样法是指在程序中添加一些代码以收集更多与程序运行时的行为相关的信息的方法。取样法的常见做法是,在程序中添加 printf 函数来打印不同阶段的值。

但这种做法会带来更多的编辑和编译,在程序的错误修复后,需要把这些额外的代码删除掉。

或者使用这样的方法来包含调试代码:

#ifdef DEBUG
    printf("variable x has value = %d\n", x);
#endif

在编译程序时,加上编译标志 -DDEBUG 就可以启用这个代码。甚至可以设置一个调试级别:

#define BASIC_DEBUG 1
#define EXTRA_DEBUG 2
#define SUPER_DEBUG 4

#if (DEBUG & EXTRA_DEBUG)
    printf...
#endif

这时候可以给 DEBUG 设置一个值来和某个 DEBUG 级别做位与操作: -DDEBUG=5 将启用 BASIC_DEBUG 和 SUPER_DEBUG 。

将其设置为0则代表关闭所有调试信息。

C语言还提供了一些宏帮助我们进行调试。比如:__LINE__, __FILE__, __DATE__, __TIME__

无需重新编译的调试技巧

可以在程序中添加一个作为调试标志的全局变量,这使得用户可以在命令行上通过一个选项来决定是否启用调试模式。比如:

if (debug) {
    ...
}

这样做的好处是,如果运行时出现了问题,就可以打开调试功能,诊断错误,而无需重新编译。

但这样做也有明显的不足,就是程序的长度会增加,但这只是一个表面问题,算不上实际的问题,往往不会对程序的性能造成真正的影响。

程序的受控执行

较复杂的调试器可以在源代码级别查看程序的比较详细的状态信息。 GNU 的调试器 gdb 就可以做到这一点。

为了能够调试程序,我们需要在编译它时加上一个或多个特殊的编译器选项。这些选项的作用是让编译器在程序中添加额外的调试信息。这些信息包括各种符号和源代码行号,调试器将利用这些信息向用户显示程序已经执行到源代码的位置。

-g 标志是常用的一个编译选项。我们必须在编译每个源文件时都加上这个选项,对链接器也要这么做,它使用特殊版本的C语言标准库以提供库函数中的调试支持。

调试信息的加入将使可执行程序的长度成倍增加(最高可达10倍)。尽管可执行程序的容量可能增加了,但程序运行时所需要的内存数量还是和原来一样,程序调试结束后,可以将调试信息从程序的发行版本中删除。

Note

可以使用命令 strip <file> 将可执行文件的调试信息删除而不需要重新编译程序。

使用 gdb 进行调试

启动 gdb

启动 gdb 的方式是:

$ gdb <exe_file>

进入会话后,敲入 help 可以查看在线帮助。

gdb 本身是一个基于文本的应用程序,但它为一些重复性的任务准备了一些快捷键。比如方向键可以回卷以前的命令。如果直接按下回车则是执行“空命令”,代表再次执行一遍上一次的命令。在使用单步命令 step 或 next 时空命令非常有用。

敲入 quit 命令即可退出 gdb 。

运行一个程序

使用 run 命令来执行程序。在 run 命令中给出的参数会作为程序的参数传递给程序。

如果程序运行不正确(发生段错误), gdb 会报告出失败的原因及位置。

栈跟踪

我们可以用 backtrace 命令来查看出程序是如何达到一个位置的:

(gdb) backtrace

backtrace 可以简写为 bt 。 为了与其他调试器兼容, where 命令也可以达成这个功能。

检查变量

栈跟踪的信息可以看到函数和其参数的信息。

我们可以用 print 命令给出变量和其他表达式的内容:

(gdb) print j
$1 = 4

这表明 j 的值是4 。$1是伪变量, gdb 保存输出了以备后用。后续的命令将把它们的输出依次保存到 $2 $3 ... 。

伪变量 $ 的结果总是最后一次操作的结果,倒数第二次是 $$ 。这使得我们可以把某次操作的结果用在另一个命令中:

(gdb) print j
$3 = 4
(gdb) print a[$-1].key

列出程序源代码

我们可以直接在 gdb 里用 list 命令列出程序的源代码。这个命令会打印围绕当前位置前后的一段代码,如果继续使用 list 命令,将显示更多代码,还可以给 list 命令提供一个行号或函数名作为参数,它将显示指定位置前后的代码。

设置断点

我们可以通过设置断点在任一位置停止程序的运行,这将中断程序的运行并将控制权返回给调试器。然后我们即可对变量进行检查并让程序从断点位置继续执行。

有很多命令可以设置断点,用 help breakpoint 命令可以列出这些命令。

使用 break 命令下一个断点:

(gdb) break 21

在在21行下了一个断点。

断点被触发后,使用 cont 命令继续程序的执行。直到又遇到了一个断点。

可以使用 display 命令告诉 gdb ,在每次程序停在断点位置时自动显示变量内容:

(gdb) display array

我们可以用 commands 命令在程序达到断点位置时需要执行的调试器命令。

(gdb) commands
Type commands for ....
> cont
> end

通过 info display 和 info break 可以查看设置的断点情况,然后通过 disable break 1 和 disable display 1 来禁用设置。

Note

commands 指的是为 break 1 设置的命令,如果还有 break 2 或更多,则可以用 commands 2 。

用调试器打补丁

通过将断点的设置与相应的操作结合起来,就可以尝试修改程序(通常也被称为打补丁)而不需要改变程序的源代码并重新编译。

使用 set 命令来设置变量的值:

(gdb) set variable n = n+1

其他调试工具

介绍了一个检查代码的工具 lint ,函数调用工具 ctags cxref cflow ,产生执行存档的工具 prof/gprof 。

gprof

编译程序时,给编译器加上 -pg 标志(针对 gprof 程序)就可以创建出 profile 程序。 gprof 可以根据 profile 程序运行时所产生的执行跟踪文件打印出一个报告:

$ gcc -pg -o program program.c

程序用特殊版本的C语言函数链接起来并且将包括监控代码。程序执行结束后,监控数据被写入 gmon.out ( 针对 gprof )。

My

然后通过 gprof program 就可以查询报告结果。

断言

assert 的作用是测试某个假设是否成立,如果不成立就停止程序的运行。

assert(expr) 对 expr 求值,如果结果为0,它就往标准错误写一些诊断信息,然后调用 abort 函数结束程序的运行。

此宏的定义受 NDEBUG 的影响,如果在引入 assert.h 头之前已经定义了 NDEBUG ,就不定义 assert 宏。

内存调试

内存块通常都是由 malloc 函数分配给函数指针变量的。如果指针变量的取值发生了变化,又没有其他指针指向这块内存,这块内存就变得无法访问,这就是内存泄漏

如果在一个已分配的内存块尾部的后面(或者在其头的前面)写数据,就很可能损坏 malloc 库用于记录内存分配情况的数据结构。之后的 malloc 调用,甚至是一个 free 调用都会引发段错误,并导致程序崩溃。这就是缓冲区溢出错误( stack over flow )

通过一些介绍的工具可以检查这些错误,比如 ElectricFence 函数库,它可以在出现内存使用错误时立刻使程序终止,并打印相关信息。

valgrind 可以检查代码中可能出现的内存错误。